iT邦幫忙

2022 iThome 鐵人賽

DAY 25
1
Modern Web

真的好想離開 Vue 3 新手村 feat. CompositionAPI系列 第 25

Day 25: 來發 API 吧!Pinia 語法學完馬上用

  • 分享至 

  • xImage
  •  

前言

Day 21: 來發 API 吧!Async Composition API setup() 中,是在元件準備渲染時,才在 setup 內發 API 取得渲染需要的資料,通常會有兩種作法,但無論選擇哪個,使用者都需要經過「從『沒有資料可以渲染』到『渲染完成』」這個或長或短的過渡期。

今日目標:在進入頁面前,先從 API 拿到資料。

而在 Day 23: 來發 API 吧!Lifecycle Hooks and Navigation Guards 你要哪一個? 中提到,可以在進入路由頁面前,先發 API 取得渲染需要的資料,再進入元件的生命週期,但這個方式要搭配 Pinia 一起使用。

今天會寫講解 Pinia 的基礎語法,最後用 f2e 旅館預約頁面示範:使用 Pinia + Router Guard,在進入路由前先發 API,將頁面需要的狀態準備好,如果已經熟悉 Pinia 的人可以直接跳到最後一段。

Outline

  • 安裝 Pinia
  • Pinia 基礎語法
    • store
    • state
    • getters
    • actions
  • 來發 API 吧!
    • 在進入路由前準備好 state:Pinia + Router Guard

安裝 Pinia

如果是透過 npm init vue@latest 指令安裝,在 main.js 引用 Pinia 的部份已經都寫好了。

自行安裝

npm install pinia
import { createApp } from "vue";
import { createPinia } from "pinia";

import App from "./App.vue";

const app = createApp(App);

app.use(createPinia());

app.mount("#app");

Pinia 基礎語法

Basic 範例

因為 Pinia 在使用上實在很直觀(相較於他的哥哥 Vuex),所以這裡直接先上範例,讓大家大致理解他的用法,再來ㄧㄧ介紹 store、state、getters、actions。

定義 store:包含 state、getters、actions

import { defineStore } from "pinia";

export const useCounterStore = defineStore('counter', {
  //定義狀態初始值
  state: () => ({ count: 0, name: 'Eduardo' }),
  //對狀態加工的 getters,如同 computed
  getters: {
    doubleCount: (state) => state.count * 2,
  },
  //定義使用到的函式,可以為同步和非同步,如同 method
  actions: {
    increment() {
      this.count++
    },
  },
})

在元件引用

在元件引入並呼叫 useCounterStore(),就可以在 scripttemplate 內取得 store 裡的狀態跟方法等等。

//inside <script setup>
import { useCounterStore } from "@/stores/counter.js";

const counterStore = useCounterStore();
<template>
  <div>
    <h2>counter</h2>
    <!-- 取得的 state 會響應式更新 -->
    <p>{{ counterStore.count }}</p>
    
    <!-- 可以呼叫 store 內定義的方法來修改 state -->
    <button @click="counterStore.increment">+</button>
    <!-- 也可以直接修改 state -->
    <button @click="counterStore.count++">+</button>
  </div>
<template>

Store

在 Pinia 可以將狀態分成不同的 store 進行管理,例如:飯店有住宿預約 API 和餐飲預約 API,就可以分成兩個 store 管理,store 會個別定義在不同 .js 檔中;不過,store 之間還是可以取得彼此的 state、getter 或 action。

Store 有分成 Option Stores 和 Setup Stores 兩種寫法,前者是 Option API 的風格,後者是 Composition API 的風格,Option Stores 比較好上手,這篇的範例都是使用 Option Stores。

defineStore

Syntax

defineStore('name/id', {
	//other options
})
  • id:Pinia 會用這個名字來連結到 Vue devtool,必須是獨一無二、不可重複的。
  • other options:主要為 state、getters、actions

使用範例
文件上建議 defineStore 所回傳的 function,用 use... 作為開頭,例如下面範例:

export const useHotelStore = defineStore("hotel", {
  state: () => ({
      //定義 state 初始值
  }),
  getters: {
      //定義 getters
  },
  actions: {
      //定義 actions
  },
});

使用 store

store 的實體要到 useStore() 被呼叫的時候才會建立,實體化之後,就可以存取定義在 stategettersactions 中的屬性。
store 是一個由 reactive 返回的響應式物件(即 Proxy),所以不可以直接解構,會失去響應性

就是想解構使用

如果想解構使用,不想每次都用 store.stateName 去取 state,需要將 store 實例傳入 storeToRefs(storeInstance) 再來解構。

  • state 和 getters 直接解構會失去響應性,需要從 storeToRefs(store) 解構
  • action 可以直接從原 store 解構,從 storeToRefs(store) 解構反而會失去響應性
import { useCounterStore } from "@/stores/counter.js";
import { storeToRefs } from "pinia";
const counterStore = useCounterStore();

// state 和 getters 直接解構會失去響應性,所以需要從 storeToRefs(store) 解構
const { count, name, doubleCount } = storeToRefs(counterStore);
// action 可以直接解構,從 storeToRefs(store) 解構反而會失去響應性
const { increment } = counterStore;

補充
為什麼丟進 storeToRefs 後就可以解構,這其實是 Vue 3 toRef()toRefs() API 做的事情,好奇的話可以再看這兩個 API。


每個 Store 主要分成三個部份:state、getters、actions。

state

  • state 要回傳一個物件,裡面定義所有用到的 state 和初始值
  • 沒有辦法動態加入新的 state 到 store 中,所有要共用的狀態都必須要 store 內先定義初始值
  • 可以透過 storeInstance.stateName 直接取得 state
import { defineStore } from "pinia";

export const useHotelStore = defineStore("hotel", {
  state: () => ({
    rooms: [],
    room: {},
  }),
});

改動 state

  1. 修改單一 state:直接修改

    const counterStore = useCounterStore();
    counterStore.count++;
    
  2. 一次修改單個或多個 state:storeInstance.$patch()
    根據傳進去的參數不同,分成兩個寫法。
    a. 傳入物件key 為 state 名稱、value 為更新的值

    store.$patch({stateName1: newValue}, {stateName2: newValue})
    
    const counterStore = useCounterStore();
    
    counterStore.$patch({ count: 100 })
    counterStore.$patch({ count: 100, name: 'Angela' })
    

    b. 傳入回呼函式,函式可以取得 store 內的 state 進行操作

    store.$patch((state) => {
        //直接操作 state
    }
    

    傳入回呼函式的好處在於,當 state 裡面為陣列,想直接對陣列做順序的操作,而不是重新賦值一個新的陣列時。

    store.$patch((state) => {
        state.arr.reverse();
    }
    

    是否一定要用 $patch? btw 這位是 Pinia 的主要開發者~

  3. 將 state 初始化:storeInstance.$reset
    會將 store 內的狀態全部初始化,回到預設的初始值

    const counterStore = useCounterStore();
    counterStore.$reset();
    

監聽 state 變化:$subscribe

Syntax

storeInstance.$subscribe((mutation, state) => { ... })
  • 會在 state 每次改變後觸發
  • 如果是透過 .$patch 一次修改多個 state,只會觸發一次
  • 通常會在元件 setup 內建立監聽($subscribe),這個監聽器會綁定元件實例,在元件銷毀時一起銷毀

裡面的 callback function 可以拿到兩個參數,分別是

  • mutation 物件:
    • type:這次改動是透過什麼方式
      1. 'direct':直接改動,如 store.count++
      2. 'patch object':透過 $patch() 傳入物件來改動
      3. 'patch object'::透過 $patch() 傳入函式來改動
    • storeId:被監聽的 store id
    • payload:傳入 $patch() 內的參數,如果是直接改動 state 則為 undefined
  • state:
    被監聽的 store 實例內的整個 state 物件

文件範例:

counterStore.$subscribe((mutation, state) => {
  mutation.type;  
  mutation.storeId;
  mutation.payload; 

  //可以在這裡將整個 state 存到 localStorage
  localStorage.setItem("cart", JSON.stringify(state));
});

getter

基本上,getter 可以想成是 Vue 的 computed,特性十分相似

  • 可以透過 storeInstance.getterName 直接取得 getter 回傳的結果
  • 可以根據 state 做加工並緩存結果,接受 state 作為第一個參數
  • 如果需要使用其他 getter 加工的結果來加工,可以透過 this 取到整個 Store 的實例,但記得不可以使用箭頭函式
  • 無法處理非同步工作
export const useStore = defineStore('hotel', {
  state: () => ({
    rooms: [],
  }),
  getters: {
    doubleCount: (state) => state.counter * 2,
    singleRooms: (state) =>
      state.rooms.filter((room) => room.name.includes("Single")),
    //也可以取得其他 getter 的結果來加工
    //記得用到 this 就不能使用箭頭函式
    deluxeSingleRooms() {
      return this.singleRooms.filter((room) => room.name.includes("Deluxe"));
    },
    
})

在模板中取用

<template>
  <div>
    <p v-for="room in hotelStore.singleRooms" :key="room.id">{{ room.name }}</p>
  </div>
</template>

傳參數給 getter

  • 基本上,有緩存功能的 getter 沒有辦法傳參數
  • 如果需要傳參數則要回傳一個接收參數的函式,這個函式可以透過閉包取得 state,所以可以根據參數和 state 做加工或運算,但這個方式沒有辦法緩存,每次呼叫都會重新執行運算
    (以上兩點跟 computed 也是一樣的~)
export const useStore = defineStore('hotel', {
  state: () => ({
    rooms: [],
  }),
  getters: {
    getRoomById: (state) => {
      return (roomId) => state.rooms.find((room) => room.id === roomId);
    },
  },
})

actions

基本上,actions 可以想成是 Vue 的 method

  • 可以透過 storeInstance.actionName 直接取用函式
  • 可以傳參數
  • 可以透過 this 取得整個 store 實例,但就不得使用箭頭函式
  • 可以處理非同步工作,並在內部使用 await

以下是根據之前封裝好的方法,來接六角 F2E 的旅館 API:
這裡只示範 GET 所有房型&取得單一房型資訊的 actions,在呼叫後會將 response 存到 state 中,這樣所有元件都可以取到最新的共用狀態

export const useHotelStore = defineStore("hotel", {
  state: () => ({
    rooms: [],
    room: {},
  }),
  actions: {
    async getRooms() {
      try {
        const { success, items: rooms } = await API.GET("rooms");
        if (success) {
          this.rooms = rooms;
        } else {
          console.warn("getRooms fail");
        }
      } catch (error) {
        console.log(error);
      }
    },
    async getRoom(roomID) {
      try {
        const { room } = await API.GET(`rooms/${roomID}`);
        this.room = room[0];
      } catch (error) {
        console.log(error);
      }
    },
  },
});

store to store

要在 store 的 getter 或 action 取得其他的 store 做處理很簡單,只需要引入 store,並在函式內呼叫建立實例,就可以取得其他 store 定義的內容。

import { useOtherStore } from './other-store';
import { useAuthStore } from './auth-store';

export const useStore = defineStore('main', {
  state: () => ({
    // ...
  }),
  getters: {
    otherGetter(state) {
      const otherStore = useOtherStore()
      return state.localData + otherStore.data
    },
  },
  actions: {
    async fetchUserPreferences() {
      const auth = useAuthStore()
      if (auth.isAuthenticated) {
        this.preferences = await fetchPreferences()
      } else {
        throw new Error('User must be authenticated')
      }
    },
  },
})

來發 API 吧!

接下來會透過 Pinia + Router Guard,在進入路由前,準備好頁面需要的 state。
比起準備開始渲染頁面/元件(進入元件的生命週期)時才發 API,這個方式最大的優點是:可以避免使用者看到準備資料的過渡期

  1. 先在 route 內,針對 Rooms 頁面定義 Route Guard

    • 因為很確定是針對 RoomsView.vue 這個頁面,也就是進入 /Rooms 之前,所以選擇 beforeEnter 這個 guard,可以針對個別 route 定義,需要寫在 /Rooms 的 route 下。
    • beforeEnter 可以接受非同步函式,可以使用 await,等到非同步處理完成後再跳轉。
    import { createRouter, createWebHistory } from "vue-router";
    import hotelAPI from "@/api/service";
    import { useHotelStore } from "@/stores/hotel.js";
    
    const router = createRouter({
      history: createWebHistory(import.meta.env.BASE_URL),
      routes: [
        //略
        {
          path: "/rooms",
          name: "rooms",
          component: () => import("../views/RoomsView.vue"),
          async beforeEnter(to, from) {
            const hotelStore = useHotelStore();
            await hotelStore.getRooms();
          },
        },
      ],
    });
    export default router;
    
  2. 在頁面檔案內,可以直接從 store 取得更新的狀態

    • 在 script 引入並建立 store 實例,就可以在元件的 script 和 template 內取到 hotelStore 下的狀態(當然還有 getters 和 actions,但這裡目前不需要)。
    import { useHotelStore } from "@/stores/hotel.js";
    
    const hotelStore = useHotelStore();
    

    可以在 template 內直接取用 hotelStore 下的狀態,不需要特別在 script 取出並重新命名。

    <template>
      <div class="wrapper">
        <h2>Rooms View</h2>
        <router-link
          :to="`/room/${room.id}`"
          v-for="room in hotelStore.rooms"
          :key="room.id"
        >
          <RoomCard :room="room" class="room" />
        </router-link>
      </div>
    </template>
    

參考資料


上一篇
Day 24: Before Pinia - 什麼是狀態(state)?為什麼需要狀態管理器?
下一篇
Day 26: 在 Vue router - Navigation Guard 中使用 Pinia store 的小眉角
系列文
真的好想離開 Vue 3 新手村 feat. CompositionAPI31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言